From 49f38efdbe3718055d2c84847d7dab92ae359be9 Mon Sep 17 00:00:00 2001
From: Mohamed Bassem <me@mbassem.com>
Date: Tue, 1 Jul 2025 20:26:25 +0000
Subject: feat: Add a proper reader mode

---
 apps/web/app/reader/[bookmarkId]/page.tsx | 312 ++++++++++++++++++++++++++++++
 1 file changed, 312 insertions(+)
 create mode 100644 apps/web/app/reader/[bookmarkId]/page.tsx

(limited to 'apps/web/app/reader/[bookmarkId]/page.tsx')

diff --git a/apps/web/app/reader/[bookmarkId]/page.tsx b/apps/web/app/reader/[bookmarkId]/page.tsx
new file mode 100644
index 00000000..7c2b0c9e
--- /dev/null
+++ b/apps/web/app/reader/[bookmarkId]/page.tsx
@@ -0,0 +1,312 @@
+"use client";
+
+import { Suspense, useState } from "react";
+import { useRouter } from "next/navigation";
+import HighlightCard from "@/components/dashboard/highlights/HighlightCard";
+import ReaderView from "@/components/dashboard/preview/ReaderView";
+import { Button } from "@/components/ui/button";
+import { FullPageSpinner } from "@/components/ui/full-page-spinner";
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { Slider } from "@/components/ui/slider";
+import {
+  HighlighterIcon as Highlight,
+  Minus,
+  Plus,
+  Printer,
+  Settings,
+  Type,
+  X,
+} from "lucide-react";
+
+import { api } from "@karakeep/shared-react/trpc";
+import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
+import { getBookmarkTitle } from "@karakeep/shared/utils/bookmarkUtils";
+
+export default function ReaderViewPage({
+  params,
+}: {
+  params: { bookmarkId: string };
+}) {
+  const bookmarkId = params.bookmarkId;
+  const { data: highlights } = api.highlights.getForBookmark.useQuery({
+    bookmarkId,
+  });
+  const { data: bookmark } = api.bookmarks.getBookmark.useQuery({
+    bookmarkId,
+  });
+
+  const router = useRouter();
+  const [fontSize, setFontSize] = useState([18]);
+  const [lineHeight, setLineHeight] = useState([1.6]);
+  const [fontFamily, setFontFamily] = useState("serif");
+  const [showHighlights, setShowHighlights] = useState(false);
+  const [showSettings, setShowSettings] = useState(false);
+
+  const fontFamilies = {
+    serif: "ui-serif, Georgia, Cambria, serif",
+    sans: "ui-sans-serif, system-ui, sans-serif",
+    mono: "ui-monospace, Menlo, Monaco, monospace",
+  };
+
+  const onClose = () => {
+    if (window.history.length > 1) {
+      router.back();
+    } else {
+      router.push("/dashboard");
+    }
+  };
+
+  const handlePrint = () => {
+    window.print();
+  };
+
+  return (
+    <div className="min-h-screen bg-background">
+      {/* Header */}
+      <header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 print:hidden">
+        <div className="flex h-14 items-center justify-between px-4">
+          <div className="flex items-center gap-2">
+            <Button variant="ghost" size="icon" onClick={onClose}>
+              <X className="h-4 w-4" />
+            </Button>
+            <span className="text-sm text-muted-foreground">Reader View</span>
+          </div>
+
+          <div className="flex items-center gap-2">
+            <Button variant="ghost" size="icon" onClick={handlePrint}>
+              <Printer className="h-4 w-4" />
+            </Button>
+
+            <Popover open={showSettings} onOpenChange={setShowSettings}>
+              <PopoverTrigger asChild>
+                <Button variant="ghost" size="icon">
+                  <Settings className="h-4 w-4" />
+                </Button>
+              </PopoverTrigger>
+              <PopoverContent side="bottom" align="end" className="w-80">
+                <div className="space-y-4">
+                  <div className="flex items-center gap-2 pb-2">
+                    <Type className="h-4 w-4" />
+                    <h3 className="font-semibold">Reading Settings</h3>
+                  </div>
+
+                  <div className="space-y-4">
+                    <div className="space-y-2">
+                      <label className="text-sm font-medium">Font Family</label>
+                      <Select value={fontFamily} onValueChange={setFontFamily}>
+                        <SelectTrigger>
+                          <SelectValue />
+                        </SelectTrigger>
+                        <SelectContent>
+                          <SelectItem value="serif">Serif</SelectItem>
+                          <SelectItem value="sans">Sans Serif</SelectItem>
+                          <SelectItem value="mono">Monospace</SelectItem>
+                        </SelectContent>
+                      </Select>
+                    </div>
+
+                    <div className="space-y-2">
+                      <div className="flex items-center justify-between">
+                        <label className="text-sm font-medium">Font Size</label>
+                        <span className="text-sm text-muted-foreground">
+                          {fontSize[0]}px
+                        </span>
+                      </div>
+                      <div className="flex items-center gap-2">
+                        <Button
+                          variant="outline"
+                          size="icon"
+                          className="h-7 w-7 bg-transparent"
+                          onClick={() =>
+                            setFontSize([Math.max(12, fontSize[0] - 1)])
+                          }
+                        >
+                          <Minus className="h-3 w-3" />
+                        </Button>
+                        <Slider
+                          value={fontSize}
+                          onValueChange={setFontSize}
+                          max={24}
+                          min={12}
+                          step={1}
+                          className="flex-1"
+                        />
+                        <Button
+                          variant="outline"
+                          size="icon"
+                          className="h-7 w-7 bg-transparent"
+                          onClick={() =>
+                            setFontSize([Math.min(24, fontSize[0] + 1)])
+                          }
+                        >
+                          <Plus className="h-3 w-3" />
+                        </Button>
+                      </div>
+                    </div>
+
+                    <div className="space-y-2">
+                      <div className="flex items-center justify-between">
+                        <label className="text-sm font-medium">
+                          Line Height
+                        </label>
+                        <span className="text-sm text-muted-foreground">
+                          {lineHeight[0]}
+                        </span>
+                      </div>
+                      <Slider
+                        value={lineHeight}
+                        onValueChange={setLineHeight}
+                        max={2.5}
+                        min={1.2}
+                        step={0.1}
+                      />
+                    </div>
+                  </div>
+                </div>
+              </PopoverContent>
+            </Popover>
+
+            <Button
+              variant={showHighlights ? "default" : "ghost"}
+              size="icon"
+              onClick={() => setShowHighlights(!showHighlights)}
+            >
+              <Highlight className="h-4 w-4" />
+            </Button>
+          </div>
+        </div>
+      </header>
+
+      <div className="flex overflow-hidden">
+        {/* Mobile backdrop */}
+        {showHighlights && (
+          <button
+            className="fixed inset-0 top-14 z-40 bg-black/50 lg:hidden"
+            onClick={() => setShowHighlights(false)}
+            onKeyDown={(e) => {
+              if (e.key === "Escape") {
+                setShowHighlights(false);
+              }
+            }}
+            aria-label="Close highlights sidebar"
+          />
+        )}
+
+        {/* Main Content */}
+        <main
+          className={`flex-1 overflow-x-hidden transition-all duration-300 ${showHighlights ? "lg:mr-80" : ""}`}
+        >
+          <article className="mx-auto max-w-3xl overflow-x-hidden px-4 py-8 sm:px-6">
+            {bookmark ? (
+              <>
+                {/* Article Header */}
+                <header className="mb-8 space-y-4">
+                  <h1
+                    className="font-bold leading-tight"
+                    style={{
+                      fontFamily:
+                        fontFamilies[fontFamily as keyof typeof fontFamilies],
+                      fontSize: `${fontSize[0] * 1.8}px`,
+                      lineHeight: lineHeight[0] * 0.9,
+                    }}
+                  >
+                    {getBookmarkTitle(bookmark)}
+                  </h1>
+                  <div className="flex items-center gap-4 text-sm text-muted-foreground">
+                    {bookmark.content.type == BookmarkTypes.LINK && (
+                      <span>By {bookmark.content.author}</span>
+                    )}
+                    <Separator orientation="vertical" className="h-4" />
+                    <span>8 min</span>
+                  </div>
+                </header>
+
+                {/* Article Content */}
+                <Suspense fallback={<FullPageSpinner />}>
+                  <div className="overflow-x-hidden">
+                    <ReaderView
+                      className="prose prose-neutral max-w-none break-words dark:prose-invert [&_code]:break-all [&_img]:h-auto [&_img]:max-w-full [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto"
+                      style={{
+                        fontFamily:
+                          fontFamilies[fontFamily as keyof typeof fontFamilies],
+                        fontSize: `${fontSize[0]}px`,
+                        lineHeight: lineHeight[0],
+                      }}
+                      bookmarkId={bookmarkId}
+                    />
+                  </div>
+                </Suspense>
+              </>
+            ) : (
+              <FullPageSpinner />
+            )}
+          </article>
+        </main>
+
+        {/* Mobile backdrop */}
+        {showHighlights && (
+          <button
+            className="fixed inset-0 top-14 z-40 bg-black/50 lg:hidden"
+            onClick={() => setShowHighlights(false)}
+            onKeyDown={(e) => {
+              if (e.key === "Escape") {
+                setShowHighlights(false);
+              }
+            }}
+            aria-label="Close highlights sidebar"
+          />
+        )}
+
+        {/* Highlights Sidebar */}
+        {showHighlights && highlights && (
+          <aside className="fixed right-0 top-14 z-50 h-[calc(100vh-3.5rem)] w-full border-l bg-background sm:w-80 lg:z-auto lg:bg-background/95 lg:backdrop-blur lg:supports-[backdrop-filter]:bg-background/60 print:hidden">
+            <div className="flex h-full flex-col">
+              <div className="border-b p-4">
+                <div className="flex items-center justify-between">
+                  <h2 className="font-semibold">Highlights</h2>
+                  <div className="flex items-center gap-2">
+                    <span className="text-sm text-muted-foreground">
+                      {highlights.highlights.length} saved
+                    </span>
+                    <Button
+                      variant="ghost"
+                      size="icon"
+                      className="h-6 w-6 lg:hidden"
+                      onClick={() => setShowHighlights(false)}
+                    >
+                      <X className="h-4 w-4" />
+                    </Button>
+                  </div>
+                </div>
+              </div>
+
+              <div className="flex-1 overflow-auto p-4">
+                <div className="space-y-4">
+                  {highlights.highlights.map((highlight) => (
+                    <HighlightCard
+                      key={highlight.id}
+                      highlight={highlight}
+                      clickable={true}
+                    />
+                  ))}
+                </div>
+              </div>
+            </div>
+          </aside>
+        )}
+      </div>
+    </div>
+  );
+}
-- 
cgit v1.2.3-70-g09d2

